diff --git a/assets/src/bundles/vault/vault-create-tasks.js b/assets/src/bundles/vault/vault-create-tasks.js index 420b92cf..591d139d 100644 --- a/assets/src/bundles/vault/vault-create-tasks.js +++ b/assets/src/bundles/vault/vault-create-tasks.js @@ -1,155 +1,155 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, csrfPost, htmlAlert} from 'utils/functions'; const alertStyle = { 'position': 'fixed', 'left': '1rem', 'bottom': '1rem', 'z-index': '100000' }; export async function vaultRequest(objectType, swhid) { let vaultUrl; if (objectType === 'directory') { vaultUrl = Urls.api_1_vault_cook_flat(swhid); } else { - vaultUrl = Urls.api_1_vault_cook_gitfast(swhid); + vaultUrl = Urls.api_1_vault_cook_git_bare(swhid); } // check if object has already been cooked const response = await fetch(vaultUrl); const data = await response.json(); // object needs to be cooked if (data.exception === 'NotFoundExc' || data.status === 'failed') { // if last cooking has failed, remove previous task info from localStorage // in order to force the recooking of the object swh.vault.removeCookingTaskInfo([swhid]); $(`#vault-cook-${objectType}-modal`).modal('show'); // object has been cooked and should be in the vault cache, // it will be asked to cook it again if it is not } else if (data.status === 'done') { $(`#vault-fetch-${objectType}-modal`).modal('show'); } else { const cookingServiceDownAlert = $(htmlAlert('danger', 'Archive cooking service is currently experiencing issues.
' + 'Please try again later.', true)); cookingServiceDownAlert.css(alertStyle); $('body').append(cookingServiceDownAlert); } } async function addVaultCookingTask(objectType, cookingTask) { const swhidsContext = swh.webapp.getSwhIdsContext(); cookingTask.origin = swhidsContext[objectType].context.origin; cookingTask.path = swhidsContext[objectType].context.path; cookingTask.browse_url = swhidsContext[objectType].swhid_with_context_url; if (!cookingTask.browse_url) { cookingTask.browse_url = swhidsContext[objectType].swhid_url; } let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks) { vaultCookingTasks = []; } if (vaultCookingTasks.find(val => { return val.bundle_type === cookingTask.bundle_type && val.swhid === cookingTask.swhid; }) === undefined) { let cookingUrl; if (cookingTask.bundle_type === 'flat') { cookingUrl = Urls.api_1_vault_cook_flat(cookingTask.swhid); } else { - cookingUrl = Urls.api_1_vault_cook_gitfast(cookingTask.swhid); + cookingUrl = Urls.api_1_vault_cook_git_bare(cookingTask.swhid); } if (cookingTask.email) { cookingUrl += '?email=' + cookingTask.email; } try { const response = await csrfPost(cookingUrl); handleFetchError(response); vaultCookingTasks.push(cookingTask); localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); $('#vault-cook-directory-modal').modal('hide'); $('#vault-cook-revision-modal').modal('hide'); const cookingTaskCreatedAlert = $(htmlAlert('success', 'Archive cooking request successfully submitted.
' + `Go to the Downloads page ` + 'to get the download link once it is ready.', true)); cookingTaskCreatedAlert.css(alertStyle); $('body').append(cookingTaskCreatedAlert); } catch (_) { $('#vault-cook-directory-modal').modal('hide'); $('#vault-cook-revision-modal').modal('hide'); const cookingTaskFailedAlert = $(htmlAlert('danger', 'Archive cooking request submission failed.', true)); cookingTaskFailedAlert.css(alertStyle); $('body').append(cookingTaskFailedAlert); } } } function validateEmail(email) { const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(String(email).toLowerCase()); } export function cookDirectoryArchive(swhid) { const email = $('#swh-vault-directory-email').val().trim(); if (!email || validateEmail(email)) { const cookingTask = { 'bundle_type': 'flat', 'swhid': swhid, 'email': email, 'status': 'new' }; addVaultCookingTask('directory', cookingTask); } else { $('#invalid-email-modal').modal('show'); } } export async function fetchDirectoryArchive(directorySwhid) { $('#vault-fetch-directory-modal').modal('hide'); const vaultUrl = Urls.api_1_vault_cook_flat(directorySwhid); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); } export function cookRevisionArchive(revisionId) { const email = $('#swh-vault-revision-email').val().trim(); if (!email || validateEmail(email)) { const cookingTask = { - 'bundle_type': 'gitfast', + 'bundle_type': 'git_bare', 'swhid': revisionId, 'email': email, 'status': 'new' }; addVaultCookingTask('revision', cookingTask); } else { $('#invalid-email-modal').modal('show'); } } export async function fetchRevisionArchive(revisionSwhid) { $('#vault-fetch-directory-modal').modal('hide'); - const vaultUrl = Urls.api_1_vault_cook_gitfast(revisionSwhid); + const vaultUrl = Urls.api_1_vault_cook_git_bare(revisionSwhid); const response = await fetch(vaultUrl); const data = await response.json(); swh.vault.fetchCookedObject(data.fetch_url); } diff --git a/assets/src/bundles/vault/vault-table-row.ejs b/assets/src/bundles/vault/vault-table-row.ejs index e40d2e5d..01299387 100644 --- a/assets/src/bundles/vault/vault-table-row.ejs +++ b/assets/src/bundles/vault/vault-table-row.ejs @@ -1,56 +1,57 @@ <%# Copyright (C) 2020 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information %> <% if (cookingTask.object_type === 'directory') { %> <% } else { %> .gitfast.gz | git fast-import"> +$ tar xvf <%= cookingTask.swhid %>.git.tar +$ git clone <%= cookingTask.swhid %>.git new_repository +$ cd new_repository/"> <% } %>
<% if (cookingTask.origin) { %> <%= decodeURIComponent(cookingTask.origin) %> <% } else { %> unknown <% } %> <%= cookingTask.object_type %> id: <%= cookingTask.swhid %> <% if (cookingTask.path) { %>
path: <%= cookingTask.path %> <% } %> <%- progressBar.outerHTML %> <% if (cookingTask.status === 'done') { %> <% } %> diff --git a/assets/src/bundles/vault/vault-ui.js b/assets/src/bundles/vault/vault-ui.js index facf4539..ffe03eb8 100644 --- a/assets/src/bundles/vault/vault-ui.js +++ b/assets/src/bundles/vault/vault-ui.js @@ -1,258 +1,258 @@ /** * Copyright (C) 2018-2019 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import {handleFetchError, handleFetchErrors, csrfPost} from 'utils/functions'; import vaultTableRowTemplate from './vault-table-row.ejs'; const progress = `
;`; const pollingInterval = 5000; let checkVaultId; function updateProgressBar(progressBar, cookingTask) { if (cookingTask.status === 'new') { progressBar.css('background-color', 'rgba(128, 128, 128, 0.5)'); } else if (cookingTask.status === 'pending') { progressBar.css('background-color', 'rgba(0, 0, 255, 0.5)'); } else if (cookingTask.status === 'done') { progressBar.css('background-color', '#5cb85c'); } else if (cookingTask.status === 'failed') { progressBar.css('background-color', 'rgba(255, 0, 0, 0.5)'); progressBar.css('background-image', 'none'); } progressBar.text(cookingTask.progress_message || cookingTask.status); if (cookingTask.status === 'new' || cookingTask.status === 'pending') { progressBar.addClass('progress-bar-animated'); } else { progressBar.removeClass('progress-bar-striped'); } } let recookTask; // called when the user wants to download a cooked archive export async function fetchCookedObject(fetchUrl) { recookTask = null; // first, check if the link is still available from the vault const response = await fetch(fetchUrl); // link is still alive, proceed to download if (response.ok) { $('#vault-fetch-iframe').attr('src', fetchUrl); // link is dead } else { // get the associated cooking task const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); for (let i = 0; i < vaultCookingTasks.length; ++i) { if (vaultCookingTasks[i].fetch_url === fetchUrl) { recookTask = vaultCookingTasks[i]; break; } } // display a modal asking the user if he wants to recook the archive $('#vault-recook-object-modal').modal('show'); } } // called when the user wants to recook an archive // for which the download link is not available anymore export async function recookObject() { if (recookTask) { // stop cooking tasks status polling clearTimeout(checkVaultId); // build cook request url let cookingUrl; if (recookTask.bundle_type === 'flat') { cookingUrl = Urls.api_1_vault_cook_flat(recookTask.swhid); } else { - cookingUrl = Urls.api_1_vault_cook_gitfast(recookTask.swhid); + cookingUrl = Urls.api_1_vault_cook_git_bare(recookTask.swhid); } if (recookTask.email) { cookingUrl += '?email=' + recookTask.email; } try { // request archive cooking const response = await csrfPost(cookingUrl); handleFetchError(response); // update task status recookTask.status = 'new'; const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); for (let i = 0; i < vaultCookingTasks.length; ++i) { if (vaultCookingTasks[i].swhid === recookTask.swhid) { vaultCookingTasks[i] = recookTask; break; } } // save updated tasks to local storage localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); // hide recook archive modal $('#vault-recook-object-modal').modal('hide'); // restart cooking tasks status polling await checkVaultCookingTasks(); } catch (_) { // something went wrong $('#vault-recook-object-modal').modal('hide'); await checkVaultCookingTasks(); } } } async function checkVaultCookingTasks() { const vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks || vaultCookingTasks.length === 0) { $('.swh-vault-table tbody tr').remove(); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); return; } const cookingTaskRequests = []; const tasks = {}; const currentObjectIds = []; for (let i = 0; i < vaultCookingTasks.length; ++i) { const cookingTask = vaultCookingTasks[i]; if (typeof cookingTask.object_type !== 'undefined') { // Legacy cooking task, upgrade it to the new schema if (cookingTask.object_type === 'directory') { cookingTask.swhid = `swh:1:dir:${cookingTask.object_id}`; cookingTask.bundle_type = 'flat'; cookingTask.fetch_url = Urls.api_1_vault_fetch_flat(cookingTask.swhid); } else if (cookingTask.object_type === 'revision') { cookingTask.swhid = `swh:1:rev:${cookingTask.object_id}`; - cookingTask.bundle_type = 'gitfast'; - cookingTask.fetch_url = Urls.api_1_vault_fetch_gitfast(cookingTask.swhid); + cookingTask.bundle_type = 'git_bare'; + cookingTask.fetch_url = Urls.api_1_vault_fetch_git_bare(cookingTask.swhid); } else { // Log to the console + Sentry console.error(`Unexpected cookingTask.object_type: ${cookingTask.object_type}`); // Ignore it for now and hope a future version will fix it continue; } delete cookingTask.object_type; delete cookingTask.object_id; } currentObjectIds.push(cookingTask.swhid); tasks[cookingTask.swhid] = cookingTask; let cookingUrl; if (cookingTask.bundle_type === 'flat') { cookingUrl = Urls.api_1_vault_cook_flat(cookingTask.swhid); } else { - cookingUrl = Urls.api_1_vault_cook_gitfast(cookingTask.swhid); + cookingUrl = Urls.api_1_vault_cook_git_bare(cookingTask.swhid); } if (cookingTask.status !== 'done' && cookingTask.status !== 'failed') { cookingTaskRequests.push(fetch(cookingUrl)); } } $('.swh-vault-table tbody tr').each((i, row) => { const swhid = $(row).find('.vault-object-info').data('swhid'); if ($.inArray(swhid, currentObjectIds) === -1) { $(row).remove(); } }); try { const responses = await Promise.all(cookingTaskRequests); handleFetchErrors(responses); const cookingTasks = await Promise.all(responses.map(r => r.json())); const table = $('#vault-cooking-tasks tbody'); for (let i = 0; i < cookingTasks.length; ++i) { const cookingTask = tasks[cookingTasks[i].swhid]; cookingTask.status = cookingTasks[i].status; cookingTask.fetch_url = cookingTasks[i].fetch_url; cookingTask.progress_message = cookingTasks[i].progress_message; } for (let i = 0; i < vaultCookingTasks.length; ++i) { const cookingTask = vaultCookingTasks[i]; const rowTask = $(`#vault-task-${CSS.escape(cookingTask.swhid)}`); if (!rowTask.length) { let browseUrl = cookingTask.browse_url; if (!browseUrl) { browseUrl = Urls.browse_swhid(cookingTask.swhid); } const progressBar = $.parseHTML(progress)[0]; const progressBarContent = $(progressBar).find('.progress-bar'); updateProgressBar(progressBarContent, cookingTask); table.prepend(vaultTableRowTemplate({ browseUrl: browseUrl, cookingTask: cookingTask, progressBar: progressBar, Urls: Urls, swh: swh })); } else { const progressBar = rowTask.find('.progress-bar'); updateProgressBar(progressBar, cookingTask); const downloadLink = rowTask.find('.vault-dl-link'); if (cookingTask.status === 'done') { downloadLink[0].innerHTML = ''; } else { downloadLink[0].innerHTML = ''; } } } localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); } catch (error) { console.log('Error when fetching vault cooking tasks:', error); } } export function removeCookingTaskInfo(tasksToRemove) { let vaultCookingTasks = JSON.parse(localStorage.getItem('swh-vault-cooking-tasks')); if (!vaultCookingTasks) { return; } vaultCookingTasks = $.grep(vaultCookingTasks, task => { return $.inArray(task.swhid, tasksToRemove) === -1; }); localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultCookingTasks)); } export function initUi() { $('#vault-tasks-toggle-selection').change(event => { $('.vault-task-toggle-selection').prop('checked', event.currentTarget.checked); }); $('#vault-remove-tasks').click(() => { clearTimeout(checkVaultId); const tasksToRemove = []; $('.swh-vault-table tbody tr').each((i, row) => { const taskSelected = $(row).find('.vault-task-toggle-selection').prop('checked'); if (taskSelected) { const swhid = $(row).find('.vault-object-info').data('swhid'); tasksToRemove.push(swhid); $(row).remove(); } }); removeCookingTaskInfo(tasksToRemove); $('#vault-tasks-toggle-selection').prop('checked', false); checkVaultId = setTimeout(checkVaultCookingTasks, pollingInterval); }); checkVaultCookingTasks(); window.onfocus = () => { clearTimeout(checkVaultId); checkVaultCookingTasks(); }; } diff --git a/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.git.tar b/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.git.tar new file mode 100644 index 00000000..13265a3f Binary files /dev/null and b/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.git.tar differ diff --git a/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.gitfast.gz b/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.gitfast.gz deleted file mode 100644 index aa70433e..00000000 Binary files a/cypress/fixtures/swh:1:rev:1c480a4573d2a003fc2630c21c2b25829de49972.gitfast.gz and /dev/null differ diff --git a/cypress/integration/vault.spec.js b/cypress/integration/vault.spec.js index fc3768b2..69d9f5fe 100644 --- a/cypress/integration/vault.spec.js +++ b/cypress/integration/vault.spec.js @@ -1,541 +1,541 @@ /** * Copyright (C) 2019-2021 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const progressbarColors = { 'new': 'rgba(128, 128, 128, 0.5)', 'pending': 'rgba(0, 0, 255, 0.5)', 'done': 'rgb(92, 184, 92)' }; function checkVaultCookingTask(objectType) { cy.contains('button', 'Download') .click(); cy.contains('.dropdown-item', objectType) .click(); cy.wait('@checkVaultCookingTask'); } function getVaultItemList() { return JSON.parse(window.localStorage.getItem('swh-vault-cooking-tasks')); } function updateVaultItemList(vaultItems) { window.localStorage.setItem('swh-vault-cooking-tasks', JSON.stringify(vaultItems)); } // Mocks API response : /api/1/vault/(:bundleType)/(:swhid) -// bundleType : {'flat', 'gitfast'} +// bundleType : {'flat', 'git_bare'} function genVaultCookingResponse(bundleType, swhid, status, message, fetchUrl) { return { 'bundle_type': bundleType, 'id': 1, 'progress_message': message, 'status': status, 'swhid': swhid, 'fetch_url': fetchUrl }; }; // Tests progressbar color, status // And status in localStorage function testStatus(taskId, color, statusMsg, status) { cy.get(`.swh-vault-table #vault-task-${CSS.escape(taskId)}`) .should('be.visible') .find('.progress-bar') .should('be.visible') .and('have.css', 'background-color', color) .and('contain', statusMsg) .then(() => { // Vault item with object_id as taskId should exist in localStorage const currentVaultItems = getVaultItemList(); const vaultItem = currentVaultItems.find(obj => obj.swhid === taskId); assert.isNotNull(vaultItem); assert.strictEqual(vaultItem.status, status); }); } describe('Vault Cooking User Interface Tests', function() { before(function() { const dirInfo = this.origin[0].directory[0]; this.directory = `swh:1:dir:${dirInfo.id}`; this.directoryUrl = this.Urls.browse_origin_directory() + `?origin_url=${this.origin[0].url}&path=${dirInfo.path}`; this.vaultDirectoryUrl = this.Urls.api_1_vault_cook_flat(this.directory); this.vaultFetchDirectoryUrl = this.Urls.api_1_vault_fetch_flat(this.directory); this.revisionId = this.origin[1].revisions[0]; this.revision = `swh:1:rev:${this.revisionId}`; this.revisionUrl = this.Urls.browse_revision(this.revisionId); - this.vaultRevisionUrl = this.Urls.api_1_vault_cook_gitfast(this.revision); - this.vaultFetchRevisionUrl = this.Urls.api_1_vault_fetch_gitfast(this.revision); + this.vaultRevisionUrl = this.Urls.api_1_vault_cook_git_bare(this.revision); + this.vaultFetchRevisionUrl = this.Urls.api_1_vault_fetch_git_bare(this.revision); const release = this.origin[1].release; this.releaseUrl = this.Urls.browse_release(release.id) + `?origin_url=${this.origin[1].url}`; this.vaultReleaseDirectoryUrl = this.Urls.api_1_vault_cook_flat(`swh:1:dir:${release.directory}`); }); beforeEach(function() { // For some reason, this gets reset if we define it in the before() hook, // so we need to define it here this.vaultItems = [ { - 'bundle_type': 'gitfast', + 'bundle_type': 'git_bare', 'swhid': this.revision, 'email': '', 'status': 'done', - 'fetch_url': `/api/1/vault/gitfast/${this.revision}/raw/`, + 'fetch_url': `/api/1/vault/git-bare/${this.revision}/raw/`, 'progress_message': null } ]; this.legacyVaultItems = [ { 'object_type': 'revision', 'object_id': this.revisionId, 'email': '', 'status': 'done', 'fetch_url': `/api/1/vault/revision/${this.revisionId}/gitfast/raw/`, 'progress_message': null } ]; this.genVaultDirCookingResponse = (status, message = null) => { return genVaultCookingResponse('flat', this.directory, status, message, this.vaultFetchDirectoryUrl); }; this.genVaultRevCookingResponse = (status, message = null) => { - return genVaultCookingResponse('gitfast', this.revision, status, + return genVaultCookingResponse('git_bare', this.revision, status, message, this.vaultFetchRevisionUrl); }; }); it('should report an error when vault service is experiencing issues', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // an internal server error cy.intercept(this.vaultDirectoryUrl, { body: {'exception': 'APIError'}, statusCode: 500 }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') .should('contain', 'Archive cooking service is currently experiencing issues.'); }); it('should report an error when a cooking task creation failed', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // a task can not be created cy.intercept('GET', this.vaultDirectoryUrl, { body: {'exception': 'NotFoundExc'} }).as('checkVaultCookingTask'); cy.intercept('POST', this.vaultDirectoryUrl, { body: {'exception': 'ValueError'}, statusCode: 500 }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check error alert is displayed cy.get('.alert-danger') .should('be.visible') .should('contain', 'Archive cooking request submission failed.'); }); it('should display previous cooking tasks', function() { updateVaultItemList(this.vaultItems); cy.visit(this.Urls.browse_vault()); cy.contains(`#vault-task-${CSS.escape(this.revision)} button`, 'Download') .click(); }); it('should display and upgrade previous cooking tasks from the legacy format', function() { updateVaultItemList(this.legacyVaultItems); cy.visit(this.Urls.browse_vault()); // Check it is displayed cy.contains(`#vault-task-${CSS.escape(this.revision)} button`, 'Download') .then(() => { // Check the LocalStorage was upgraded expect(getVaultItemList()).to.deep.equal(this.vaultItems); }); }); it('should create a directory cooking task and report the success', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept('GET', this.vaultFetchDirectoryUrl, { fixture: `${this.directory}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaulResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new'), this.genVaultDirCookingResponse('pending', 'Processing...'), this.genVaultDirCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultDirectoryUrl, req => req.reply(checkVaulResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseDirectoryUrl = swhIdsContext.directory.swhid_with_context_url; // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.browse_vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.directory, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-origin a`) .should('contain', this.origin[0].url) .should('have.attr', 'href', `${this.Urls.browse_origin()}?origin_url=${this.origin[0].url}`); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-object-info a`) .should('have.text', this.directory) .should('have.attr', 'href', browseDirectoryUrl); cy.get(`#vault-task-${CSS.escape(this.directory)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a revision cooking task and report its status', function() { cy.adminLogin(); // Browse a revision cy.visit(this.revisionUrl); // Stub response to the vault API indicating to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { - fixture: `${this.revision}.gitfast.gz`, + fixture: `${this.revision}.git.tar`, headers: { - 'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`, + 'Content-disposition': `attachment; filename=${this.revision}.git.tar`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultRevCookingResponse('new'), this.genVaultRevCookingResponse('pending', 'Processing...'), this.genVaultRevCookingResponse('done') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultRevisionUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); // Create a vault cooking task through the GUI checkVaultCookingTask('as git'); cy.window().then(win => { const swhIdsContext = win.swh.webapp.getSwhIdsContext(); const browseRevisionUrl = swhIdsContext.revision.swhid_url; // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); // Go to Downloads page cy.visit(this.Urls.browse_vault()); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['pending'], 'Processing...', 'pending'); }); cy.wait('@checkVaultCookingTask').then(() => { testStatus(this.revision, progressbarColors['done'], 'done', 'done'); }); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-origin`) .should('have.text', 'unknown'); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-object-info a`) .should('have.text', this.revision) .should('have.attr', 'href', browseRevisionUrl); cy.get(`#vault-task-${CSS.escape(this.revision)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then((xhr) => { assert.isNotNull(xhr.response.body); }); }); }); it('should create a directory cooking task from the release view', function() { // Browse a directory cy.visit(this.releaseUrl); // Stub responses when checking vault task status const checkVaultResponses = [ {'exception': 'NotFoundExc'}, this.genVaultDirCookingResponse('new') ]; // trick to override the response of an intercepted request // https://github.com/cypress-io/cypress/issues/9302 cy.intercept('GET', this.vaultReleaseDirectoryUrl, req => req.reply(checkVaultResponses.shift())) .as('checkVaultCookingTask'); // Stub responses when requesting the vault API to simulate // a task has been created cy.intercept('POST', this.vaultReleaseDirectoryUrl, { body: this.genVaultDirCookingResponse('new') }).as('createVaultCookingTask'); cy.contains('button', 'Download') .click(); // Create a vault cooking task through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@createVaultCookingTask'); // Check success alert is displayed cy.get('.alert-success') .should('be.visible') .should('contain', 'Archive cooking request successfully submitted.'); }); it('should offer to recook an archive if no longer available for download', function() { updateVaultItemList(this.vaultItems); // Send 404 when fetching vault item cy.intercept({url: this.vaultFetchRevisionUrl}, { statusCode: 404, body: { 'exception': 'NotFoundExc', 'reason': `Revision with ID '${this.revision}' not found.` }, headers: { 'Content-Type': 'json' } }).as('fetchCookedArchive'); cy.visit(this.Urls.browse_vault()) .get(`#vault-task-${CSS.escape(this.revision)} .vault-dl-link button`) .click(); cy.wait('@fetchCookedArchive').then(() => { cy.intercept('POST', this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('createVaultCookingTask'); cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('new') }).as('checkVaultCookingTask'); cy.get('#vault-recook-object-modal > .modal-dialog') .should('be.visible') .contains('button:visible', 'Ok') .click(); cy.wait('@checkVaultCookingTask') .then(() => { testStatus(this.revision, progressbarColors['new'], 'new', 'new'); }); }); }); it('should remove selected vault items', function() { updateVaultItemList(this.vaultItems); cy.visit(this.Urls.browse_vault()) .get(`#vault-task-${CSS.escape(this.revision)}`) .find('input[type="checkbox"]') .click({force: true}); cy.contains('button', 'Remove selected tasks') .click(); cy.get(`#vault-task-${CSS.escape(this.revision)}`) .should('not.exist'); }); it('should offer to immediately download a directory tarball if already cooked', function() { // Browse a directory cy.visit(this.directoryUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchDirectoryUrl}, { fixture: `${this.directory}.tar.gz`, headers: { 'Content-disposition': `attachment; filename=${this.directory}.tar.gz`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('done') }).as('checkVaultCookingTask'); // Create a vault cooking task through the GUI cy.contains('button', 'Download') .click(); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); - it('should offer to immediately download a revision gitfast archive if already cooked', function() { + it('should offer to immediately download a bare revision git archive if already cooked', function() { cy.adminLogin(); // Browse a directory cy.visit(this.revisionUrl); // Stub response to the vault API to simulate archive download cy.intercept({url: this.vaultFetchRevisionUrl}, { - fixture: `${this.revision}.gitfast.gz`, + fixture: `${this.revision}.git.tar`, headers: { - 'Content-disposition': `attachment; filename=${this.revision}.gitfast.gz`, + 'Content-disposition': `attachment; filename=${this.revision}.git.tar`, 'Content-Type': 'application/gzip' } }).as('fetchCookedArchive'); // Stub responses when requesting the vault API to simulate // the directory tarball has already been cooked cy.intercept(this.vaultRevisionUrl, { body: this.genVaultRevCookingResponse('done') }).as('checkVaultCookingTask'); checkVaultCookingTask('as git'); // Start archive download through the GUI cy.get('.modal-dialog') .contains('button:visible', 'Ok') .click(); cy.wait('@fetchCookedArchive'); }); it('should offer to recook an object if previous vault task failed', function() { cy.visit(this.directoryUrl); // Stub responses when requesting the vault API to simulate // the last cooking of the directory tarball has failed cy.intercept(this.vaultDirectoryUrl, { body: this.genVaultDirCookingResponse('failed') }).as('checkVaultCookingTask'); cy.contains('button', 'Download') .click(); // Check that recooking the directory is offered to user cy.get('.modal-dialog') .contains('button:visible', 'Ok') .should('be.visible'); }); }); diff --git a/swh/web/api/views/vault.py b/swh/web/api/views/vault.py index 3b1f9233..052d9c17 100644 --- a/swh/web/api/views/vault.py +++ b/swh/web/api/views/vault.py @@ -1,490 +1,490 @@ # Copyright (C) 2015-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import Any, Dict from django.http import HttpResponse from django.shortcuts import redirect from swh.model.identifiers import CoreSWHID, ObjectType from swh.web.api.apidoc import api_doc, format_docstring from swh.web.api.apiurls import api_route from swh.web.api.views.utils import api_lookup from swh.web.common import archive, query from swh.web.common.exc import BadInputExc from swh.web.common.utils import reverse ###################################################### # Common SWHID_RE = "swh:1:[a-z]{3}:[0-9a-z]{40}" # XXX: a bit spaghetti. Would be better with class-based views. def _dispatch_cook_progress(request, bundle_type: str, swhid: CoreSWHID): if request.method == "GET": return api_lookup( archive.vault_progress, bundle_type, swhid, notfound_msg=f"Cooking of {swhid} was never requested.", request=request, ) elif request.method == "POST": email = request.POST.get("email", request.GET.get("email", None)) return api_lookup( archive.vault_cook, bundle_type, swhid, email, notfound_msg=f"{swhid} not found.", request=request, ) def _vault_response(vault_response: Dict[str, Any]) -> Dict[str, Any]: return { "fetch_url": vault_response["fetch_url"], "progress_message": vault_response["progress_msg"], "id": vault_response["task_id"], "status": vault_response["task_status"], "swhid": str(vault_response["swhid"]), } ###################################################### # Flat bundles @api_route( f"/vault/flat/(?P{SWHID_RE})/", "api-1-vault-cook-flat", methods=["GET", "POST"], throttle_scope="swh_vault_cooking", never_cache=True, ) @api_doc("/vault/flat/") @format_docstring() def api_vault_cook_flat(request, swhid): """ .. http:get:: /api/1/vault/flat/(swhid)/ .. http:post:: /api/1/vault/flat/(swhid)/ Request the cooking of a simple archive, typically for a directory. That endpoint enables to create a vault cooking task for a directory through a POST request or check the status of a previously created one through a GET request. Once the cooking task has been executed, the resulting archive can be downloaded using the dedicated endpoint :http:get:`/api/1/vault/flat/(swhid)/raw/`. Then to extract the cooked directory in the current one, use:: $ tar xvf path/to/swh:1:*.tar.gz :param string swhid: the object's SWHID :query string email: e-mail to notify when the archive is ready {common_headers} :>json string fetch_url: the url from which to download the archive once it has been cooked (see :http:get:`/api/1/vault/flat/(swhid)/raw/`) :>json string progress_message: message describing the cooking task progress :>json number id: the cooking task id :>json string status: the cooking task status (either **new**, **pending**, **done** or **failed**) :>json string swhid: the identifier of the object to cook :statuscode 200: no error :statuscode 400: an invalid directory identifier has been provided :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ swhid = CoreSWHID.from_string(swhid) if swhid.object_type == ObjectType.DIRECTORY: res = _dispatch_cook_progress(request, "flat", swhid) res["fetch_url"] = reverse( "api-1-vault-fetch-flat", url_args={"swhid": str(swhid)}, request=request, ) return _vault_response(res) elif swhid.object_type == ObjectType.CONTENT: raise BadInputExc( "Content objects do not need to be cooked, " "use `/api/1/content/raw/` instead." ) elif swhid.object_type == ObjectType.REVISION: # TODO: support revisions too? (the vault allows it) raise BadInputExc( "Only directories can be cooked as 'flat' bundles. " "Use `/api/1/vault/gitfast/` to cook revisions, as gitfast bundles." ) else: raise BadInputExc("Only directories can be cooked as 'flat' bundles.") @api_route( r"/vault/directory/(?P[0-9a-f]+)/", "api-1-vault-cook-directory", methods=["GET", "POST"], checksum_args=["dir_id"], throttle_scope="swh_vault_cooking", never_cache=True, ) @api_doc("/vault/directory/", tags=["deprecated"]) @format_docstring() def api_vault_cook_directory(request, dir_id): """ .. http:get:: /api/1/vault/directory/(dir_id)/ This endpoint was replaced by :http:get:`/api/1/vault/flat/(swhid)/` """ _, obj_id = query.parse_hash_with_algorithms_or_throws( dir_id, ["sha1"], "Only sha1_git is supported." ) swhid = f"swh:1:dir:{obj_id.hex()}" res = _dispatch_cook_progress(request, "flat", CoreSWHID.from_string(swhid)) res["fetch_url"] = reverse( "api-1-vault-fetch-flat", url_args={"swhid": swhid}, request=request, ) return _vault_response(res) @api_route( f"/vault/flat/(?P{SWHID_RE})/raw/", "api-1-vault-fetch-flat", ) @api_doc("/vault/flat/raw/") def api_vault_fetch_flat(request, swhid): """ .. http:get:: /api/1/vault/flat/(swhid)/raw/ Fetch the cooked archive for a flat bundle. See :http:get:`/api/1/vault/flat/(swhid)/` to get more details on 'flat' bundle cooking. :param string swhid: the SWHID of the object to cook :resheader Content-Type: application/octet-stream :statuscode 200: no error :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ res = api_lookup( archive.vault_fetch, "flat", CoreSWHID.from_string(swhid), notfound_msg=f"Cooked archive for {swhid} not found.", request=request, ) fname = "{}.tar.gz".format(swhid) response = HttpResponse(res, content_type="application/gzip") response["Content-disposition"] = "attachment; filename={}".format(fname) return response @api_route( r"/vault/directory/(?P[0-9a-f]+)/raw/", "api-1-vault-fetch-directory", checksum_args=["dir_id"], ) @api_doc("/vault/directory/raw/", tags=["hidden", "deprecated"]) def api_vault_fetch_directory(request, dir_id): """ .. http:get:: /api/1/vault/directory/(dir_id)/raw/ This endpoint was replaced by :http:get:`/api/1/vault/flat/raw/` """ _, obj_id = query.parse_hash_with_algorithms_or_throws( dir_id, ["sha1"], "Only sha1_git is supported." ) rev_flat_raw_url = reverse( "api-1-vault-fetch-flat", url_args={"swhid": f"swh:1:dir:{dir_id}"} ) return redirect(rev_flat_raw_url) ###################################################### # gitfast bundles @api_route( f"/vault/gitfast/(?P{SWHID_RE})/", "api-1-vault-cook-gitfast", methods=["GET", "POST"], throttle_scope="swh_vault_cooking", never_cache=True, ) @api_doc("/vault/gitfast/") @format_docstring() def api_vault_cook_gitfast(request, swhid): """ .. http:get:: /api/1/vault/gitfast/(swhid)/ .. http:post:: /api/1/vault/gitfast/(swhid)/ Request the cooking of a gitfast archive for a revision or check its cooking status. That endpoint enables to create a vault cooking task for a revision through a POST request or check the status of a previously created one through a GET request. Once the cooking task has been executed, the resulting gitfast archive can be downloaded using the dedicated endpoint :http:get:`/api/1/vault/gitfast/(swhid)/raw/`. Then to import the revision in the current directory, use:: $ git init $ zcat path/to/swh:1:rev:*.gitfast.gz | git fast-import $ git checkout HEAD :param string swhid: the revision's permanent identifiers :query string email: e-mail to notify when the gitfast archive is ready {common_headers} :>json string fetch_url: the url from which to download the archive once it has been cooked (see :http:get:`/api/1/vault/gitfast/(swhid)/raw/`) :>json string progress_message: message describing the cooking task progress :>json number id: the cooking task id :>json string status: the cooking task status (new/pending/done/failed) :>json string swhid: the identifier of the object to cook :statuscode 200: no error :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ swhid = CoreSWHID.from_string(swhid) if swhid.object_type == ObjectType.REVISION: res = _dispatch_cook_progress(request, "gitfast", swhid) res["fetch_url"] = reverse( "api-1-vault-fetch-gitfast", url_args={"swhid": str(swhid)}, request=request, ) return _vault_response(res) elif swhid.object_type == ObjectType.CONTENT: raise BadInputExc( "Content objects do not need to be cooked, " "use `/api/1/content/raw/` instead." ) elif swhid.object_type == ObjectType.DIRECTORY: raise BadInputExc( "Only revisions can be cooked as 'gitfast' bundles. " "Use `/api/1/vault/flat/` to cook directories, as flat bundles." ) else: raise BadInputExc("Only revisions can be cooked as 'gitfast' bundles.") @api_route( r"/vault/revision/(?P[0-9a-f]+)/gitfast/", "api-1-vault-cook-revision_gitfast", methods=["GET", "POST"], checksum_args=["rev_id"], throttle_scope="swh_vault_cooking", never_cache=True, ) @api_doc("/vault/revision/gitfast/", tags=["deprecated"]) @format_docstring() def api_vault_cook_revision_gitfast(request, rev_id): """ .. http:get:: /api/1/vault/revision/(rev_id)/gitfast/ This endpoint was replaced by :http:get:`/api/1/vault/gitfast/` """ _, obj_id = query.parse_hash_with_algorithms_or_throws( rev_id, ["sha1"], "Only sha1_git is supported." ) swhid = f"swh:1:rev:{obj_id.hex()}" res = _dispatch_cook_progress(request, "gitfast", CoreSWHID.from_string(swhid)) res["fetch_url"] = reverse( "api-1-vault-fetch-gitfast", url_args={"swhid": swhid}, request=request, ) return _vault_response(res) @api_route( f"/vault/gitfast/(?P{SWHID_RE})/raw/", "api-1-vault-fetch-gitfast", ) @api_doc("/vault/gitfast/raw/") def api_vault_fetch_revision_gitfast(request, swhid): """ .. http:get:: /api/1/vault/gitfast/(swhid)/raw/ Fetch the cooked gitfast archive for a revision. See :http:get:`/api/1/vault/gitfast/(swhid)/` to get more details on gitfast cooking. :param string rev_id: the revision's sha1 identifier :resheader Content-Type: application/octet-stream :statuscode 200: no error :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ res = api_lookup( archive.vault_fetch, "gitfast", CoreSWHID.from_string(swhid), notfound_msg="Cooked archive for {} not found.".format(swhid), request=request, ) fname = "{}.gitfast.gz".format(swhid) response = HttpResponse(res, content_type="application/gzip") response["Content-disposition"] = "attachment; filename={}".format(fname) return response @api_route( r"/vault/revision/(?P[0-9a-f]+)/gitfast/raw/", "api-1-vault-fetch-revision_gitfast", checksum_args=["rev_id"], ) @api_doc("/vault/revision_gitfast/raw/", tags=["hidden", "deprecated"]) def _api_vault_revision_gitfast_raw(request, rev_id): """ .. http:get:: /api/1/vault/revision/(rev_id)/gitfast/raw/ This endpoint was replaced by :http:get:`/api/1/vault/gitfast/raw/` """ rev_gitfast_raw_url = reverse( "api-1-vault-fetch-gitfast", url_args={"swhid": f"swh:1:rev:{rev_id}"} ) return redirect(rev_gitfast_raw_url) ###################################################### # git_bare bundles @api_route( f"/vault/git-bare/(?P{SWHID_RE})/", "api-1-vault-cook-git-bare", methods=["GET", "POST"], throttle_scope="swh_vault_cooking", never_cache=True, ) @api_doc("/vault/git-bare/") @format_docstring() def api_vault_cook_git_bare(request, swhid): """ .. http:get:: /api/1/vault/git-bare/(swhid)/ .. http:post:: /api/1/vault/git-bare/(swhid)/ Request the cooking of a git-bare archive for a revision or check its cooking status. That endpoint enables to create a vault cooking task for a revision through a POST request or check the status of a previously created one through a GET request. Once the cooking task has been executed, the resulting git-bare archive can be downloaded using the dedicated endpoint :http:get:`/api/1/vault/git-bare/(swhid)/raw/`. Then to import the revision in the current directory, use:: - $ tar -xzf path/to/swh:1:rev:*.git_bare.tar.gz + $ tar -xf path/to/swh:1:rev:*.git.tar $ git clone swh:1:rev:*.git new_repository (replace ``swh:1:rev:*`` with the SWHID of the requested revision) This will create a directory called ``new_repository``, which is a git repository containing the requested objects. :param string swhid: the revision's permanent identifier :query string email: e-mail to notify when the git-bare archive is ready {common_headers} :>json string fetch_url: the url from which to download the archive once it has been cooked (see :http:get:`/api/1/vault/git-bare/(swhid)/raw/`) :>json string progress_message: message describing the cooking task progress :>json number id: the cooking task id :>json string status: the cooking task status (new/pending/done/failed) :>json string swhid: the identifier of the object to cook :statuscode 200: no error :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ swhid = CoreSWHID.from_string(swhid) if swhid.object_type == ObjectType.REVISION: res = _dispatch_cook_progress(request, "git_bare", swhid) res["fetch_url"] = reverse( "api-1-vault-fetch-git-bare", url_args={"swhid": str(swhid)}, request=request, ) return _vault_response(res) elif swhid.object_type == ObjectType.CONTENT: raise BadInputExc( "Content objects do not need to be cooked, " "use `/api/1/content/raw/` instead." ) elif swhid.object_type == ObjectType.DIRECTORY: raise BadInputExc( "Only revisions can be cooked as 'git-bare' bundles. " "Use `/api/1/vault/flat/` to cook directories, as flat bundles." ) else: raise BadInputExc("Only revisions can be cooked as 'git-bare' bundles.") @api_route( f"/vault/git-bare/(?P{SWHID_RE})/raw/", "api-1-vault-fetch-git-bare", ) @api_doc("/vault/git-bare/raw/") def api_vault_fetch_revision_git_bare(request, swhid): """ .. http:get:: /api/1/vault/git-bare/(swhid)/raw/ Fetch the cooked git-bare archive for a revision. See :http:get:`/api/1/vault/git-bare/(swhid)/` to get more details on git-bare cooking. :param string swhid: the revision's permanent identifier :resheader Content-Type: application/octet-stream :statuscode 200: no error :statuscode 404: requested directory did not receive any cooking request yet (in case of GET) or can not be found in the archive (in case of POST) """ res = api_lookup( archive.vault_fetch, "git_bare", CoreSWHID.from_string(swhid), notfound_msg="Cooked archive for {} not found.".format(swhid), request=request, ) - fname = "{}.git_bare.tar.gz".format(swhid) + fname = "{}.git.tar".format(swhid) response = HttpResponse(res, content_type="application/gzip") response["Content-disposition"] = "attachment; filename={}".format(fname) return response diff --git a/swh/web/templates/includes/vault-create-tasks.html b/swh/web/templates/includes/vault-create-tasks.html index e7bc0f3e..ced734ee 100644 --- a/swh/web/templates/includes/vault-create-tasks.html +++ b/swh/web/templates/includes/vault-create-tasks.html @@ -1,175 +1,175 @@ {% comment %} Copyright (C) 2017-2021 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load swh_templatetags %} {% if vault_cooking %} {% if user.is_staff %} {% else %} {% endif %} {% include "includes/vault-common.html" %} {% endif %}